跳到主要内容

接口:从协议到抽象基类

序列的协议

Python 数据模型的哲学是尽量支持基本协议。对序列来说,即便是最简单的实现,Python 也会力求做到最好。

image-20240705162700864

鉴于序列协议的重要性,如果没有 itercontains 方法,Python 会调用 getitem 方法,设法让迭代和 in 运算符可用

抽象基类的类型

collections.abc 模块中定义了 16 个抽象基类

image-20240705165447399

  • Iterable、Container 和 Sized
    • 各个集合应该继承这三个抽象基类,或者至少实现兼容的协议。Iterable 通过 __iter__ 方法支持迭代,Container 通过 __contains__ 方法支持 in 运算符,Sized 通过 __len__ 方法支持 len() 函数。
  • Sequence、Mapping 和 Set
    • 这三个是主要的不可变集合类型,而且各自都有可变的子类:MutableSequence、MutableMapping 和 MutableSet
  • MappingView
    • 在 Python 3 中,映射方法 .items().keys().values() 返回的对象分别是 ItemsView、KeysView 和 ValuesView 的实例
  • Callable 和 Hashable
    • 这两个抽象基类与集合没有太大的关系,只不过因为 collections.abc 是标准库中定义抽象基类的第一个模块,而它们又太重要了,因此才把它们放到 collections.abc 模块中。
    • 我从未见过 Callable 或 Hashable 的子类。这两个抽象基类的主要作用是为内置函数 isinstance 提供支持,以一种安全的方式判断对象能不能调用或散列。
    • 若想检查是否能调用,可以使用内置的 callable() 函数;但是没有类似的 hashable() 函数,因此测试对象是否可散列,最好使用 isinstance(my_obj, Hashable)
  • Iterator
    • 注意它是 Iterable 的子类。

抽象基类编写规则

声明抽象基类最简单的方式是继承 abc.ABC 或其他抽象基类。

然而,abc.ABC 是 Python 3.4 新增的类,因此如果你使用的是旧版 Python,那么无法继承现有的抽象基类。此时,必须在 class 语句中使用 metaclass= 关键字,把值设为 abc.ABCMeta

class Tombola(metaclass=abc.ABCMeta):
# ...

metaclass= 关键字参数是 Python 3 引入的。在 Python 2 中必须使用 __metaclass__ 类属性:

class Tombola(object):  # 这是Python 2!!!
__metaclass__ = abc.ABCMeta
# ...

除了 @abstractmethod 之外,abc 模块还定义了 @abstractclassmethod、@abstractstaticmethod 和 @abstractproperty 三个装饰器。然而,后三个装饰器从 Python 3.3 起废弃了,因为装饰器可以在 @abstractmethod 上堆叠,那三个就显得多余了。例如,声明抽象类方法的推荐方式是:

class MyABC(abc.ABC):
@classmethod
@abc.abstractmethod
def an_abstract_classmethod(cls, ...):
pass

与其他方法描述符一起使用时,abstractmethod() 应该放在最里层;也就是说,在 @abstractmethod 和 def 语句之间不能有其他装饰器

例子:定义并使用一个抽象基类

Tombola 抽象基类有四个方法,其中两个是抽象方法。

  • .load(...):把元素放入容器。
  • .pick():从容器中随机拿出一个元素,返回选中的元素。

另外两个是具体方法。

  • .loaded():如果容器中至少有一个元素,返回 True。
  • .inspect():返回一个有序元组,由容器中的现有元素构成,不会修改容器的内容(内部的顺序不保留)。

image-20240705170255180

import abc

class Tombola(abc.ABC):

@abc.abstractmethod
def load(self, iterable):
"""从可迭代对象中添加元素。"""

@abc.abstractmethod
def pick(self):
"""随机删除元素,然后将其返回。

如果实例为空,这个方法应该抛出`LookupError`。
"""

def loaded(self):
"""如果至少有一个元素,返回`True`,否则返回`False`。"""
return bool(self.inspect())


def inspect(self):
"""返回一个有序元组,由当前元素构成。"""
items = []
while True:
try:
items.append(self.pick())
except LookupError:
break
self.load(items)
return tuple(sorted(items))
  1. 自己定义的抽象基类要继承 abc.ABC
  2. 抽象方法使用 @abstractmethod 装饰器标记,而且定义体中通常只有文档字符串
  3. 根据文档字符串,如果没有元素可选,应该抛出 LookupError
  4. 抽象基类是可以包含具体方法的
  5. 抽象基类中的具体方法只能依赖抽象基类定义的接口(即只能使用抽象基类中的其他具体方法、抽象方法或特性)
  6. 我们不知道具体子类如何存储元素,不过为了得到 inspect 的结果,我们可以不断调用 .pick() 方法,把 Tombola 清空……
  7. ……然后再使用 .load(...) 把所有元素放回去

注意,实现 .inspect() 方法采用的迂回方式要求捕获 self.pick() 抛出的 LookupError。self.pick() 抛出 LookupError 这一事实也是接口的一部分,但是在 Python 中没办法声明,只能在文档中说明

选择使用 LookupError 异常的原因是,在 Python 的异常层次关系中,它与 IndexError 和 KeyError 有关,这两个是具体实现 Tombola 所用的数据结构最有可能抛出的异常。据此,实现代码可能会抛出 LookupError、IndexError 或 KeyError 异常。

完整的层次结构参见 https://docs.python.org/dev/library/exceptions.html#exception-hierarchy

定义 Tombola 抽象基类的子类

import random

from tombola import Tombola


class BingoCage(Tombola):

def __init__(self, items):
self._randomizer = random.SystemRandom()
self._items = []
self.load(items)

def load(self, items):
self._items.extend(items)
self._randomizer.shuffle(self._items)

def pick(self):
try:
return self._items.pop()
except IndexError:
raise LookupError('pick from empty BingoCage')

def __call__(self):
self.pick()
  1. 明确指定 BingoCage 类扩展 Tombola 类。
  2. 假设我们将在线上游戏中使用这个。random.SystemRandom 使用 os.urandom(...) 函数实现 random API。根据 os 模块的文档(http://docs.python.org/3/library/os.html#os.urandom),os.urandom(...) 函数生成“适合用于加密”的随机字节序列。
  3. 委托 .load(...) 方法实现初始加载。
  4. 没有使用 random.shuffle() 函数,而是使用 SystemRandom 实例的 .shuffle() 方法。
  5. 多了一个方法 __call__;添加额外的方法没有问题

下面是是 Tombola 接口的另一种实现,虽然与之前不同,但完全有效。LotteryBlower 打乱“数字球”后没有取出最后一个,而是取出一个随机位置上的球。

import random

from tombola import Tombola


class LotteryBlower(Tombola):

def __init__(self, iterable):
self._balls = list(iterable)

def load(self, iterable):
self._balls.extend(iterable)

def pick(self):
try:
position = random.randrange(len(self._balls))
except ValueError:
raise LookupError('pick from empty LotteryBlower')
return self._balls.pop(position)

def loaded(self):
return bool(self._balls)

def inspect(self):
return tuple(sorted(self._balls))
  1. 初始化方法接受任何可迭代对象:把参数构建成列表。
  2. 如果范围为空,random.randrange(...) 函数抛出 ValueError,为了兼容 Tombola,我们捕获它,抛出 LookupError。
  3. 否则,从 self._balls 中取出随机选中的元素。
  4. 覆盖 loaded 方法,避免调用 inspect 方法(示例 11-9 中的 Tombola.loaded 方法是这么做的)。我们可以直接处理 self._balls 而不必构建整个有序元组,从而提升速度。
  5. 使用一行代码覆盖 inspect 方法。

示例中有个习惯做法值得指出:在 __init__ 方法中,self._balls 保存的是 list(iterable),而不是 iterable 的引用(即没有直接把 iterable 赋值给 self._balls)。

  • 这样做使得 LotteryBlower 更灵活,因为 iterable 参数可以是任何可迭代的类型。把元素存入列表中还确保能取出元素。
  • 就算 iterable 参数始终传入列表,list(iterable) 会创建参数的副本,这依然是好的做法,因为我们要从中删除元素,而客户可能不希望自己提供的列表被修改。

定义 Tombola 的虚拟子类

白鹅类型的一个基本特性(也是值得用水禽来命名的原因):即便不继承,也有办法把一个类注册为抽象基类的虚拟子类。这样做时,我们保证注册的类忠实地实现了抽象基类定义的接口,而 Python 会相信我们,从而不做检查。如果我们说谎了,那么常规的运行时异常会把我们捕获。

注册虚拟子类的方式是在抽象基类上调用 register 方法。这么做之后,注册的类会变成抽象基类的虚拟子类,而且 issubclass 和 isinstance 等函数都能识别,但是注册的类不会从抽象基类中继承任何方法或属性。

register 方法通常作为普通的函数调用,不过也可以作为装饰器使用。在下面的示例中,我们使用装饰器句法实现了 TomboList 类,这是 Tombola 的一个虚拟子类

image-20240705173006476
from random import randrange

from tombola import Tombola

@Tombola.register # ➊
class TomboList(list): # ➋

def pick(self):
if self: # ➌
position = randrange(len(self))
return self.pop(position) # ➍
else:
raise LookupError('pop from empty TomboList')

load = list.extend # ➎

def loaded(self):
return bool(self) # ➏

def inspect(self):
return tuple(sorted(self))

# Tombola.register(TomboList) # ➐
  1. 把 Tombolist 注册为 Tombola 的虚拟子类。
  2. Tombolist 扩展 list。
  3. Tombolist 从 list 中继承 __bool__ 方法,列表不为空时返回 True。
  4. pick 调用继承自 list 的 self.pop 方法,传入一个随机的元素索引。
  5. Tombolist.load 与 list.extend 一样。
  6. loaded 方法委托 bool 函数。
  7. 如果是 Python 3.3 或之前的版本,不能把 .register 当作类装饰器使用,必须使用标准的调用句法。

注册之后,可以使用 issubclass 和 isinstance 函数判断 TomboList 是不是 Tombola 的子类:

>>> from tombola import Tombola
>>> from tombolist import TomboList
>>> issubclass(TomboList, Tombola)
True
>>> t = TomboList(range(100))
>>> isinstance(t, Tombola)
True

然而,类的继承关系在一个特殊的类属性中指定—— __mro__,即方法解析顺序(Method Resolution Order)。这个属性的作用很简单,按顺序列出类及其超类,Python 会按照这个顺序搜索方法。但是查看 TomboList 类的 __mro__ 属性,你会发现它只列出了“真实的”超类,即 list 和 object:

>>> TomboList.__mro__
(<class 'tombolist.TomboList'>, <class 'list'>, <class 'object'>)

Tombolist.__mro__ 中没有 Tombola,因此 Tombolist 没有从 Tombola 中继承任何方法。

Tombola 子类的测试方法

我编写的 Tombola 示例测试脚本用到两个类属性,用它们内省类的继承关系。

  • __subclasses__():这个方法返回类的直接子类列表,不含虚拟子类。
  • _abc_registry:只有抽象基类有这个数据属性,其值是一个 WeakSet 对象,即抽象类注册的虚拟子类的弱引用。

为了测试 Tombola 的所有子类,我编写的脚本迭代 Tombola.__subclasses__()Tombola._abc_registry 得到的列表,然后把各个类赋值给在 doctest 中使用的 ConcreteTombola。

这个测试脚本成功运行时输出的结果如下:

$ python3 tombola_runner.py
BingoCage 24 tests, 0 failed - OK
LotteryBlower 24 tests, 0 failed - OK
TumblingDrum 24 tests, 0 failed - OK
TomboList 24 tests, 0 failed - OK
import doctest

from tombola import Tombola

# 要测试的模块
import bingo, lotto, tombolist, drum ➊

TEST_FILE = 'tombola_tests.rst'
TEST_MSG = '{0:16} {1.attempted:2} tests, {1.failed:2} failed - {2}'


def main(argv):
verbose = '-v' in argv
real_subclasses = Tombola.__subclasses__()
virtual_subclasses = list(Tombola._abc_registry)

for cls in real_subclasses + virtual_subclasses:
test(cls, verbose)


def test(cls, verbose=False):
res = doctest.testfile(
TEST_FILE,
globs={'ConcreteTombola': cls},
verbose=verbose,
optionflags=doctest.REPORT_ONLY_FIRST_FAILURE)
tag = 'FAIL' if res.failed else 'OK'
print(TEST_MSG.format(cls.__name__, res, tag))

if __name__ == '__main__':
import sys
main(sys.argv)
  1. 导入包含 Tombola 真实子类和虚拟子类的模块,用于测试。
  2. __subclasses__() 返回的列表是内存中存在的直接子代。即便源码中用不到想测试的模块,也要将其导入,因为要把那些类载入内存。
  3. _abc_registry(WeakSet 对象)转换成列表,这样方能与 __subclasses__() 的结果拼接起来。
  4. 迭代找到的各个子类,分别传给 test 函数。
  5. 把 cls 参数(要测试的类)绑定到全局命名空间里的 ConcreteTombola 名称上,供 doctest 使用。
  6. 输出测试结果,包含类的名称、尝试运行的测试数量、失败的测试数量,以及 'OK' 或 'FAIL' 标记。

doctest 文件如下:

==============
Tombola tests
==============

Every concrete subclass of Tombola should pass these tests.


Create and load instance from iterable::

>>> balls = list(range(3))
>>> globe = ConcreteTombola(balls)
>>> globe.loaded()
True
>>> globe.inspect()
(0, 1, 2)


Pick and collect balls::

>>> picks = []
>>> picks.append(globe.pick())
>>> picks.append(globe.pick())
>>> picks.append(globe.pick())


Check state and results::

>>> globe.loaded()
False
>>> sorted(picks) == balls
True


Reload::

>>> globe.load(balls)
>>> globe.loaded()
True
>>> picks = [globe.pick() for i in balls]
>>> globe.loaded()
False


Check that `LookupError` (or a subclass) is the exception
thrown when the device is empty::

>>> globe = ConcreteTombola([])
>>> try:
... globe.pick()
... except LookupError as exc:
... print('OK')
OK


Load and pick 100 balls to verify that they all come out::

>>> balls = list(range(100))
>>> globe = ConcreteTombola(balls)
>>> picks = []
>>> while globe.inspect():
... picks.append(globe.pick())
>>> len(picks) == len(balls)
True
>>> set(picks) == set(balls)
True


Check that the order has changed and is not simply reversed::
>>> picks != balls
True
>>> picks[::-1] != balls
True

Note: the previous 2 tests have a *very* small chance of failing
even if the implementation is OK. The probability of the 100
balls coming out, by chance, in the order they were inspect is
1/100!, or approximately 1.07e-158. It's much easier to win the
Lotto or to become a billionaire working as a programmer.

THE END

鹅的行为有可能像鸭子

Alex 在他写的“水禽和抽象基类”一文中指出,即便不注册,抽象基类也能把一个类识别为虚拟子类。下面是他举的例子,我添加了一些代码,使用 issubclass 做测试:

>>> class Struggle:
... def __len__(self): return 23
...
>>> from collections import abc
>>> isinstance(Struggle(), abc.Sized)
True
>>> issubclass(Struggle, abc.Sized)
True

经 issubclass 函数确认(isinstance 函数也会得出相同的结论),Struggle 是 abc.Sized 的子类,这是因为 abc.Sized 实现了一个特殊的类方法,名为 __subclasshook__

class Sized(metaclass=ABCMeta):

__slots__ = ()

@abstractmethod
def __len__(self):
return 0

@classmethod
def __subclasshook__(cls, C):
if cls is Sized:
if any("__len__" in B.__dict__ for B in C.__mro__): # ➊
return True # ➋
return NotImplemented # ➌
  1. C.__mro__ (即 C 及其超类)中所列的类来说,如果类的 __dict__ 属性中有名为 __len__ 的属性……
  2. ……返回 True,表明 C 是 Sized 的虚拟子类。
  3. 否则,返回 NotImplemented,让子类检查。

__subclasshook__ 在白鹅类型中添加了一些鸭子类型的踪迹。我们可以使用抽象基类定义正式接口,可以始终使用 isinstance 检查,也可以完全使用不相关的类,只要实现特定的方法即可(或者做些事情让 __subclasshook__ 信服)。当然,只有提供 __subclasshook__ 方法的抽象基类才能这么做。

在自己定义的抽象基类中要不要实现 __subclasshook__ 方法呢?可能不需要。我在 Python 源码中只见到 Sized 这一个抽象基类实现了 __subclasshook__ 方法,而 Sized 只声明了一个特殊方法,因此只用检查这么一个特殊方法。鉴于 __len__ 方法的“特殊性”,我们基本可以确定它能做到该做的事。但是对其他特殊方法和基本的抽象基类来说,很难这么肯定。

  • 例如,虽然映射实现了 __len____getitem____iter__,但是不应该把它们视作 Sequence 的子类型,因为不能使用整数偏移值获取元素,也不能保证元素的顺序。
  • 当然,OrderedDict 除外,它保留了插入元素的顺序,但是不支持通过偏移获取元素。

在你我自己编写的抽象基类中实现 __subclasshook__ 方法,可靠性很低。我可不相信随便一个实现或继承了 load、pick、inspect 和 loaded 的类(如 Spam)的行为一定像 Tombola。程序员最好让 Spam 继承 Tombola,至少也要注册(Tombola.register(Spam)),从而确保这一点。当然,自己实现的 __subclasshook__ 方法还可以检查方法签名和其他特性,但我觉得不值得这么做。